feat(vmm): run virtio-net on vhost-net with configurable queue pairs - #1145
feat(vmm): run virtio-net on vhost-net with configurable queue pairs#1145kvinwang wants to merge 2 commits into
Conversation
a350589 to
0d3ad45
Compare
|
Reviewed against 1. 2. Queue-echo mismatch leaks the prepared interface; upgrade order is undocumented. 3. Existing nodes change their QEMU command line on upgrade with no config change. 4. Context that may be useful in the docs:
Separately, |
|
Reference notes on how clouds pick a queue count — background for later tuning. dstack operators set 16 is a tuning convention, not a spec limit
RHEL's "up to 16" is host-thread budget (one How clouds encode it on the instance typeThey publish default per NIC, max per NIC, and often an instance-wide quota. The SKU fills the hypervisor knob. AWS ENA — per SKU three columns. General-purpose default plateaus at 8; network-optimized default goes to 16/32. Formula:
GCP — formula from machine type + NIC driver, override at create.
Aliyun ECS — closest to a SKU column. Bind-to-type applies the default; API exposes
OpenStack Nova —
Azure — SKU capability is Accelerated Networking (SR-IOV), not virtio queue count. Different dataplane. Practices that show up across those sourcesLined up with the numbers already in this PR (short connections 22k→6k conn/s from 1q to 8q; 64B UDP ~600k→3.0 Mpps). TDX makes the left-hand side worse than plain KVM: a cross-vCPU wakeup is an IPI plus a VM exit.
|
0d3ad45 to
fd756b8
Compare
fd756b8 to
2452f6f
Compare
Problem
A CVM's virtio-net NIC cannot exceed one core's worth of packet processing, no
matter how many vCPUs it has. The VMM builds
-netdev bridge,...with novhost=, and a device line hardcoded tovirtio-net-pci,netdev=net0,mac=...with no
mq=on. Every packet is drained by QEMU's single main-loop thread:Sampled on a production CVM (
phala-tdx-prod7, pid 3543413, 16 vCPU / 32 GiB,-netdev bridge,id=net0,br=dstack-br0):That matches the reported symptom exactly — clean at 31k pps, 36% loss at 40k.
The three busy TAPs on that host had lifetime drop rates of 26.7% / 19.3% /
17.8%, hundreds of millions of packets each. The drops are at the TAP, so
guest-side
rx_droppedandsoftnet_statstay at zero and the cliff lookslike a network fault rather than a host CPU limit. Outbound guest traffic uses
the same thread, so a chatty guest pays it twice.
docs/bridge-networking.mdclaimed this was unavoidable: "vhost-net ... isnot enabled for bridge mode. TDX encrypts guest memory, which prevents the
host kernel from performing DMA-based packet offload." That is wrong. A TDX
guest's virtio rings and buffers live in shared, unencrypted memory precisely
so a host-side backend can reach them — which is why
vhost-vsock-pci, whichdstack has always used, works.
Fix
Two settings on
[cvm.networking], defaulting to vhost on and one queue pair,each overridable per VM through the deployment RPC:
Queue pairs are not a node setting: they default to the VM's vCPU count, capped
at
max_net_queues, because the useful number follows the VM rather than thehost. A deployment overrides that per VM.
vectorsis derived, never configured:2N + 2. A single queue pair emits nomq=onorvectors=at all, so a 1-vCPU VM and any VM deployed with--net-queues 1keep the historical command line byte for byte.Each backend needed different plumbing:
userbridgetap,br=,helper=,vhost=onbridgenetdev accepts neithervhost=norqueues=; the same setuidqemu-bridge-helperworks behind atapnetdev, so the VMM still needs noCAP_NET_ADMINbridge, >1 queuetap,ifname=,queues=Nmulti_queueTAP — now also for unfiltered nodesbridge+ libvirt filtertap,ifname=,vhost=onvhost=offis now the configured valuemacvtaptap,fds=a:b,vhost=on/dev/tapNonce per queue; netd creates the link with matchingnumtxqueues/numrxqueuescustomqueuesstill drives the device line, which is what makes a hand-written netdev usable with multiqueueBecause macvtap now consumes several descriptors per NIC, the fd layout moved
into one
macvtap_fd_layout()that both the launcher's open list and the-netdevarguments derive from, replacing the3 + indexconvention the twosites previously duplicated.
QEMU aborts when
vhost=oncannot open/dev/vhost-net, and it does so frominside the launcher where the reason is easy to miss. The VMM warns when the
device node is missing, but deliberately does not pre-flight its own access to
it: QEMU need not share the VMM's credentials, and with vhost defaulting to on,
a VMM that is merely not in the
kvmgroup would otherwise refuse everydeployment on a host where QEMU can open the device fine.
statneeds nopermission on the device, so the check stays a statement about the host.
Neither field affects
mr_config_id, which covers only the compose hash andinstance info, so retuning a NIC does not change app identity.
Verification
All five configurations deployed as real TDX CVMs on
phala-tdx-lab(kernel6.8, QEMU 8.2.2+tdx1.1, guest image
dstack-0.6.0), against an isolated bridgeand a dedicated VMM instance.
Generated command lines, read back from
/proc/<qemu>/cmdline:Host and guest state:
ethtool -l eth0combinedThe worker count is
net queue pairs + 1for vhost-vsock in every case, whichis what confirms the guest actually negotiated that many queue pairs.
netdcreated what was asked for, and cleaned up on VM removal:After removing all five VMs: no
dt*interfaces and no nwfilter bindings left.Filtering still works with vhost on.
docs/libvirt-network-filter.mdsaidflipping this bit needed "equivalent filter integration tests", so from inside
VM E, with
clean-trafficbound to the TAP andvhost=on:The nwfilter binding lives on the host TAP interface, so packets traverse it
whether QEMU or a vhost worker wrote them.
Node policy, over the RPC:
Throughput, 64-byte UDP host→guest via kernel pktgen, same 8 vCPU / 8 GiB CVM
shape on the same host (the lab host was running other tenants' CVMs, so treat
the absolute rates as noisy and the CPU columns as the signal):
The main thread goes from saturated to idle — that is the wall coming down, and
a whole core returned to the tenant. Note that vhost alone relocates the
ceiling rather than removing it: with one queue the guest's single receive queue
becomes the limit and drops reappear at a higher rate (the guest was at 99% CPU
in the 500k row). Multiqueue is what removes them. Hence vhost on by default,
queues raised deliberately.
A/B on the customer's own host (
phala-tdx-prod7, production short-connectionworkload, 4 alternating rounds to suppress noise) measured earlier in this
investigation: 10,922 → 13,933 conn/s (+27.6%), with the main thread going
94% → 0%.
Review follow-ups
A self-review pass found seven issues, all fixed in this branch:
filteroptional so netd can build unfilteredmultiqueue TAPs meant
mode = "libvirt"with an explicitfilter = ""nolonger failed — netd skipped
nwfilter-binding-createand every VM bootedonto an unbound TAP, where it previously refused to start. Config load now
rejects an empty filter in libvirt mode.
one_shot.rsstill gated its "cannot manage TAP lifecycle"error on
network_filter.mode == Libvirt, soqueues > 1on a bridge emitted-netdev tap,ifname=dt…,queues=4for a TAP nobody created. It now uses thesame
needs_netd_interfacepredicate as the server path.--net-queues 4alone was rejected despite theflag's "default: use global config" help, and on a node whose default backend
is not in
allowed_network_modes, restating the mode tripped node policy — soper-VM tuning was impossible there at all. A tuning-only request now keeps the
node's backend; policy still governs backends a caller chooses.
bridge_helper()hard-failed thelaunch when none of three hardcoded paths existed on the VMM's filesystem.
With vhost defaulting on, a working bridge node whose helper lives elsewhere
would have lost every VM on upgrade — the same mistake the
/dev/vhost-netprobe was already corrected for. It now falls back to the non-vhost
bridgenetdev with a warning.
multiqueue" warning tested the resolved value, and the shipped
vmm.tomlsets
mode = "user"withvhost = true, so it fired on every launch of astock node. Inheritance being ignored there is the documented design, not a
silent skip; the warning is gone.
resolve_requested_networkspersisted the merged values, so setting
vhost = falsenode-wide to roll backreached VMs deployed with no override but not those deployed with
--net bridge. Only what a deployment explicitly asks for is recorded now;identity-bearing fields are still pinned as before.
max_net_queueswas unvalidated.0rejected evenqueues = 1, and128let a request past the node cap only to be rejected against thedifferent
MAX_NET_QUEUES = 64bound.Re-verified on
phala-tdx-labafter the fixes:One unrelated observation from that run: these lab VMs exit after a stop/start
cycle, with
dstack-prepare.servicefailing on the second boot. A control VM onplain
usernetworking — whose netdev string this PR leaves byte-for-byteunchanged — reproduces it identically, so it is a property of the test app
(
key_provider: none), not of this change.Second review pass
Four more, all fixed:
remove_interfacedecided whether to delete an nwfilter binding by checking that
/usr/bin/virshexists. On a
mode = "none"node that has virsh installed but no reachablelibvirtd,delete_bindingfails on anything but the literal "binding notfound", and
prepare_bridgestarts by callingremove_interface— so everymultiqueue bridge VM would fail to start and leak its TAP.
Remove/Checknowcarry whether the interface was created with a binding, defaulting to true on
the wire so an older VMM's removals still clean up.
Checkreported healthy unfiltered TAPs as broken, since it rannwfilter-binding-dumpxmlfor every non-macvtap interface. Same flag.service, and
queuesis#[serde(default)], so an older netd silently built asingle-queue TAP while the VMM emitted
queues=N— the exactIFF_MULTI_QUEUEmismatch the adjacent comment warns about, surfacing only as a QEMU failure
inside the launcher. netd now echoes the queue count it created and the VMM
refuses to launch on a mismatch.
first-pass fix let
{"queues": 2}inherit the node's backend and skipallowed_network_modes, butresolve_requested_networksthen pinned thatmode, parent, and bridge into the manifest — so tuning was strictly more
powerful than naming the backend, and a later node change no longer reached
the VM. The rule is now uniform: pin what the caller named, inherit the rest.
Re-verified on
phala-tdx-labwithvirshinstalled andlibvirt_uripointed ata dead socket, which is the configuration that used to fail:
And the filtered path is unchanged, against a real libvirt:
Final regression on
phala-tdx-lab47 assertions against the final binary on a real TDX host (kernel 6.8, QEMU
8.2.2+tdx1.1, guest
dstack-0.6.0), on a dedicated bridge and VMM instance —config validation, RPC policy, generated command lines, host interface state,
guest-visible state, node rollback, and teardown. All pass.
Throughput on the final binary, 64-byte UDP host→guest via kernel pktgen, same
CVM shape on the same host:
The main thread going 88–94% → 0% is the wall coming down. The c1 column is the
nuance stated above: with one queue vhost relocates the ceiling into the guest's
receive queue rather than removing it (guest CPU 121% vs 99% in the 500k row),
which is what the multiqueue column fixes.
One observation worth recording: that multiqueue VM had 2 vCPUs, and although
QEMU was given
queues=8,vectors=18the guest reportedCombined: 2. Thevirtio-net driver uses at most one queue pair per vCPU. Over-provisioning is
therefore inert rather than harmful, and it is not rejected at deployment
because
resizecan raise the vCPU count later; this is now documented.Default queue count
Queue pairs default to
min(vcpu, 16). Two consequences are worth statingplainly, because they are behaviour changes on upgrade rather than opt-ins:
gains
mq=on,vectors=2N+2and N queue pairs where it previously had one. Thedevice is not measured —
mr_config_idcovers the compose hash and instanceinfo — so app identity is unaffected, but the guest does see a different NIC.
netdto get it.qemu-bridge-helperreturns a singledescriptor and cannot create a
multi_queueTAP. A node that has neverdeployed
netddoes not fail: bridge NICs fall back to one queue pair with awarning. A deployment that asked for a queue count explicitly still fails, so
the caller learns their request was not met rather than silently getting less.
The measured trade-off, from an 8-vCPU TDX CVM with only the guest's channel
count changed (
ethtool -L), is real in both directions:The same CVM moved 3.0 Mpps of 64-byte UDP with no loss at 8 queues against
roughly 600k at one. Bandwidth-bound workloads want the default; a VM serving
many short connections should set
--net-queues 1. Cross-vCPU wakeups cost anIPI and a VM exit under TDX, which is why the scaling is capped at 16 rather
than following large vCPU counts.
Verified on
phala-tdx-lab, 23 assertions:Surfaces and interactions
loop drains every queue on one thread, so extra queues buy little while still
costing a netd interface, more MSI-X vectors, and a changed guest device.
Anyone disabling vhost wants the old data plane, so they get the old shape.
An explicit queue count is still honoured without vhost, since that
combination is a deliberate request rather than a default.
max_net_queuesbounds what a deployment may ask for; the default's own cap is a fixed 16, so
a larger VM never silently acquires a worse default. Hard ceiling from any
source is 64.
UpdateVmchanges both fields, applying from the VM's next boot.vmm-cli.py updategrew--net,--net-vhost/--net-no-vhostand--net-queuesto reach it; the update path previously had no networkingoptions at all.
the mode selector. Leaving a control on its default emits no field, so the
node keeps owning that value.
Verified on
phala-tdx-lab(22 assertions):Third review pass
Eight more, all fixed. Three would have stopped a working node from launching
VMs, which is the failure mode a defaulted-on feature has to be judged by:
cvm.max_net_queuesnever bounded the default. Lowering it to 2 stillhanded a 16-vCPU VM sixteen queue pairs, contradicting both the vmm.toml
comment and the docs. Raising it above 16 still only widens what a caller may
request; lowering it below 16 now lowers the default too, because a node that
refuses a request for four should not hand out sixteen by itself.
dstack-vmm runbroke on every bridge VM. It never applied thenetd fallback, so with the default queue count a ≥2-vCPU bridge NIC hard-failed
with "does not manage netd interface lifecycle" on a node that worked before.
with
socket.exists(), but netd does not unlink its socket on shutdown, so astale file read as "netd is here" and the launch then failed to connect
instead of falling back. It now connects, the same way netd's own
bind-time staleness check does.
bail!and thepre-existing
response.device?returned without the rollback loop, so aversion-skewed netd left an interface on the bridge with nothing recorded to
clean it up. Both now unwind through one shared rollback.
paths still called
resolved_networks()rather than the clampedruntime_networks(), giving an already-running VM an unclamped queue countand failing
stop_vmwith a netd connect error.GetInforeported a data plane the NIC did not get. A bridge NIC thatfell back to the non-vhost
bridgenetdev for want ofqemu-bridge-helperstill reported
vhost: true. The QEMU arguments and the reported status nowread the same
effective_vhost.the NIC to user mode still submitted it — the deploy failed with no visible
control to fix.
queues/vhostare now scoped to non-user modes, likebridge_name.vmm-cli update --net-*replaced the whole NIC list, dropping extrainterfaces and un-pinning a bridge. It now merges into the VM's existing NIC
and refuses outright on a multi-NIC VM rather than guessing.
Fixing that last one surfaced a round-trip bug worth calling out on its own:
GetInforeportedparentandmacvtap_modeon every interface, includingbridge NICs that had merely inherited them from
[cvm.networking]— and thedeployment RPC rejects
parentoutside macvtap mode. Reported configurationcould be read but not sent back. Both fields are now scoped to macvtap the way
bridge_nameis scoped to bridge, with a test that a reported interfacesatisfies the RPC's own validation.
Verified on
phala-tdx-lab(11 assertions):Fourth and fifth review passes
Fourteen more findings, all fixed, and the shape of them says something about
the first three passes: nine came from the fixes those passes made.
Two root causes accounted for most of it, and both were the same mistake --
recomputing, from configuration an operator can edit at any time, a fact that
was only true at the moment something was built:
network_filter.modeoff while VMs existed orphaned TAPs and leaked theirnwfilter bindings. Interface names are a deterministic hash of the VM
identity, so the same VM comes back on the same name and inherits the
leftover ebtables rules -- silently filtered traffic on a NIC the operator
believes is unfiltered. Reproduced on a TDX host against the pre-fix build:
bindings after removal: dt77a0317f8bca clean-traffic. NICs now record whatnetd built for them.
then stopped following the node, including into a mode node policy never let
that caller choose.
Networkingnow carriesinherit_mode, and an inheritedentry reports an empty mode, which is what it was deployed with.
GetInfooutput could not be sent back. Itsconfigurationis the inputUpdateVmtakes, and bothvmm-cliand the web UI read it, change one field,and resend the rest. The node's own bridge was refused by the
allowed_bridgesgate, and
macvtap_modewas refused outright -- sovmm-cli update --net-*failed on every bridge VM on a default-configured node and on every macvtap VM.
There is now one test that asserts the property over every mode and tuning
combination, rather than the one example each earlier pass fixed:
The rest: a stopped VM reported the NICs of a finished boot rather than the
ones its next launch would build;
delete_bindingwas the one netd helper thatspawned
virshunbounded, and best-effort cleanup had just put it on everyprepare, where one unreachable libvirt could stall netd's serialized loop for
every other VM; status polling probed netd once per stopped VM, from inside the
global state lock, costing netd two warnings per probe; a queue count was
refused for a backend the caller inherited, which left the VM uneditable once
its node moved; the web UI could silently delete a NIC and renumber the rest,
changing their MAC addresses; and
netdstopped answering requests it couldnot parse, which is exactly what a VMM newer than its netd needs to read.
Verified on a TDX host: 21 assertions across the deployment, teardown,
inheritance and reporting paths, each one first shown to fail against the
pre-fix binary.
Sixth and seventh review passes
Twenty more, and the character of them finally changed: no data-plane or
teardown correctness left, but a long tail in the surfaces an operator actually
touches, and a lot of documentation that had drifted behind the code.
The knobs were unobservable.
vmm-cli.py infoprinted nothing aboutnetworking at all, and the web UI's interface panel was never extended, so the
two status fields this PR adds — the effective vhost state and queue count —
could only be read as raw JSON. That matters because both silently degrade: a
node without netd drops every defaulted bridge NIC to one queue pair, and a
missing
qemu-bridge-helperdrops it off vhost. Both now appear ininfoandin the UI, with a note when the VM is not running and the numbers are a
prediction.
vmm-cli.py update --net <mode>could not change a mode. It merged theNIC's current fields and overwrote only
mode, so switching a bridge VM to usersent a
bridge_namealong with it and was refused for a field the caller nevertyped and had no flag to clear. Mode-owned fields are now dropped on a switch.
The same shape had a second cause: a VM pins its bridge for life, and once the
operator moved the node's own default, the VM's reported configuration stopped
being accepted back. An update may now restate what its own VM already holds.
The knobs could be set but not unset —
--net-queues autoand--net-vhost-defaultnow exist,--net-queues 0is refused with a sentenceinstead of being silently discarded, and
--net-queues -1no longer surfaces aserde error naming a column offset.
Two more instances of the pattern this PR keeps hitting — deciding something
at launch and then recomputing it later from configuration that has moved:
GetInforecomputed each interface's vhost state, so an edit tocvm.qemu_bridge_helperchanged what a running VM was said to be using. Thedata plane is now settled once at launch and written down, and
VmInfo::to_pbno longer takes a
CvmConfigat all. And the single-queue fallback fired forlibvirt-filtered bridges, where it cannot help — filtering needs netd whatever
the queue count — reporting a shape no launch could produce.
Also:
GetMetaadvertised modes node policy forbids, so the deploy dialogoffered choices whose only outcome was "not allowed by node policy"; a queue
count or vhost request is now refused for a backend the caller chose and
accepted-but-dormant for one they inherited, since only the first is theirs to
correct; and a failed multiqueue prepare says a separately deployed netd may
predate multiqueue support, which the version-skew message previously missed in
the default unfiltered configuration.
Fourteen documentation claims were false or misleading, checked line by line
against the code: the queue-refusal rule was stated backwards after the code
changed under it, "byte for byte identical" applied only to the guest device
line and not the netdev,
queueswas described as a per-node setting it hasnever been, the netd TAP form was missing from the bridge row that produces it
most often, and the pre-6.4 cgroup claim was wrong in the direction that
matters. Several code comments had drifted the same way.
Operator notes
/dev/vhost-net(
root:kvm 0660— add it tokvm). On prod7 this was already true and themodule autoloaded on first open; no root, no pre-created TAPs, no
CAP_NET_ADMIN. If it is not, QEMU exits at startup and the VM does notboot.
thread whose CPU time escapes the VM's cgroup. Since 6.4 it is a
vhost_taskinside the QEMU thread group — verified on the 6.8 lab host, where the worker
tids appear under
/proc/<qemu>/task/with the same cgroup — socpu.maxand cgroup accounting still attribute it to the CVM.
netd, includingwhen
network_filter.mode = "none".--net-no-vhostrestores the previous-netdev bridge,...command lineexactly, so the old behaviour is one flag away.